fix(cli): let the dev artifact door own stack-declared security metadata on a host config - #14631
Conversation
…ata on a host config `os dev` over a HOST config composed two writers over one stack: the `new AppPlugin(config)` wrap over the config module (default `'app-plugin'` registrar) and the dev-only HMR `MetadataPlugin` over the compiled twin. Decide the door once, before the wrap, and declare `securityMetadataRegistrar: 'artifact-door'` on the wrap exactly when that door composes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…-dev-host-config-registrars
…-dev-host-config-registrars
…-dev-host-config-registrars
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5103405d4063aa015fd6bc41862162f52af08260 && git checkout 5103405d4063aa015fd6bc41862162f52af08260
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 20b883918aa787e65299e72b85c2b3396aa2006f 727d705f9299e087de3760e483c3b30620db710b && git checkout -B drift-repro 20b883918aa787e65299e72b85c2b3396aa2006f && git merge --no-ff 727d705f9299e087de3760e483c3b30620db710b
node scripts/docs-audit/affected-docs.mjs --json 20b883918aa787e65299e72b85c2b3396aa2006f
|
…disk Contract review found that gating on resolution alone regressed the very hole the change removes: `resolveDefaultArtifactPath` returns a named path verbatim without an existence check, and the door tolerates ENOENT by starting empty, so `os dev --artifact ./typo.json` over a host config left all four security collections with no registrar at all. Gate the door on `fs.existsSync` and name the missing path in the warning; drop the consequence text from the `kernel.use` catch, which cannot fire for an already-constructed plugin on an idle kernel; correct the shipped wording — the two copies differ by ADR-0010 provenance and freshness, not by parsing, since `defineStack()` is strict by default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ment The docblock, the changeset and the serve.ts comment already say what was measured; this one inline comment above the unconditional-shape assertion still carried the retired wording. The second writer's copy is strict-parsed like the door's — it lacks the ADR-0010 provenance stamp and never refreshes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 33668324620 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
Standing down from the queue on this PR, deliberately, and saying why rather than re-queueing. What happenedThis PR was armed at 17:38:58Z on head The failing check, from the triage bot's own extraction — the FILE, not the log tail: Why this is not being re-queued⛔ Not because it is "flaky". The bot's own discriminator says an The reason is that re-queueing now costs everyone and fixes nothing. Per the same comment, This PR's one re-run is therefore unspent, not forfeited. It has no prior queue failure in 24h (the bot confirms: 首次). The seat is holding it for the fix rather than burning it on a known-recurring failure. The fix is already in flight, and it is this seat's#14648 is the anchor card for exactly this test file, dispatched by this seat at 18:1xZ to a dev that is working it now, with a mandate to diagnose before repairing and a hard ⛔ on skipping, quarantining or weakening the test. The bot has since opened #14698 as a second aggregation issue for the same file; the seat will reconcile the two cards rather than let them run as separate subjects.
|
Fixes #14397
os devover a HOST config composed two writers for stack-declared security metadata over one stack. This PR makes the artifact door the single registrar on that boot shape, the waycreateStandaloneStackalready does for the artifact boot — triage's option 1, and only after the two readings triage made the dispatchable unit.The two readings, first — this card's actual deliverable
Both were taken by booting the real
os devsupervisor (packages/cli/bin/run.js dev, which compiles, writesOS_INTERNAL_ARTIFACT_PATH, and spawnsos serve --dev), never a hand-composed kernel. Full transcript on #14397.1. Does
os devcompile a HOST config intodist/objectstack.json, and is the HMR door composed? Yes to both — the path is LIVE, not dead.Case:
examples/app-showcase, a host config byisHostConfig's own rule (itsplugins[]holdsnew ConnectorOpenApiPlugin(),new ConnectorMcpPlugin(...),new ConnectorRestPlugin(...),new ConnectorSlackPlugin(...), plus the marketplace and runtime-config plugins) that declares all four ADR-0057 collections at top level.apps/objectosdoes not exist in this repo —serve.tsnames it only in a comment — so the in-repo host config above is the live case.dist/was deleted first:dev.tsnever asks what shape the config is:needsCompileis!flags.artifact && (flags.compile || !fs.existsSync(artifactPath)). It then hands the resolved path down unconditionally, andresolveDefaultArtifactPathreturns an explicit path verbatim, so the door's guard resolves on everyos devboot.The second writer is in the same boot, 620 ms after the door:
2. Which copy wins? The wrap's on a cold boot — and the door's after the first reload. The winner changes mid-run.
The card's answer was an inference from
kernel.useorder ("positionally the door's copy would land last"). That inference is falsified for the cold boot.registerInMemoryis aMap.set, so the last writer wins, and the wrap registers last.Reading registry CONTENT needs an in-process observer, so this half was measured on a minimal host config built from the repo's own examples — same composition (instantiated plugin in
plugins[], all four collections at top level, noAppPluginand noMetadataPluginof its own) plus one observer plugin that samplesmetadata.list(kind)on a timer and changes none of the three predicates under test. The compiled twin'slabelvalues were rewritten on disk so the two copies are distinguishable by value:label_packageVersiont+2sandt+8s1.0.0on all fourFour further samples over the next 45 s hold the door's copy. So the p2 note on the card is measured behaviour, not a worry: these collections have one shape from a cold boot and a different one after the first recompile, with no restart and no signal.
One correction to the card's description. The card says the module copy carries a sharing rule's
conditionas a bare string. On a CONFIG boot it does not:defineStack()defaults tostrict: trueand runsObjectStackDefinitionSchema.safeParseitself, so schema defaults and the ADR-0122 input transform are already applied to the object the wrap registers. What the module copy still lacks is the door's ADR-0010 stamp, and — the part that matters — it is a SECOND copy free to diverge from the artifact for any reason, which the flip above demonstrates. The two-writer class is real; that one detail of its description belonged to the artifact boot's raw bytes.The fix
serve.tsknows both facts in one function, so the decision is made once, next to the wrap:devArtifactDoorvalue;new AppPlugin(config, undefined, devArtifactDoor ? { securityMetadataRegistrar: 'artifact-door' } : {});kernel.usestays at its original site, after the HonoServer composition, where MetadataPlugin's route-mounting ordering requirement lives.Registrar for the four collections, by boot shape:
os dev, host config, artifact compiledapp-plugin(+ the door, two writers)artifact-door(door only)os dev, host config, artifact missing or door fails to composeapp-pluginapp-plugin(unchanged)os serve/os migrate, host configapp-pluginapp-plugin(unchanged)createStandaloneStackartifact-doorartifact-door(untouched)Why the door instance is constructed early rather than the boolean recomputed. One value has to decide both facts, or the two expressions can drift — and the drift is invisible, because a boot with no registrar looks exactly like a boot with one. Declaring the option with no door composed would be strictly worse than the divergence it removes: measured,
os serveover the same host config has a metadata service and the wrap is its ONLY writer (Registered stack-declared security metadata {"appId":"com.probe.hostcfg","count":4}, no door in that boot), so the four collections would end up with no registrar at all — the silent hole #12892 measured on the artifact boot.hasMetadataPluginis read one block earlier than before and the answer is identical:pluginsis not mutated between the two points except by the AppPlugin append itself, and an AppPlugin is not a MetadataPlugin.The door is gated on EXISTENCE, not resolution.
resolveDefaultArtifactPathhands an explicitly named path back verbatim with no existence check, and the door tolerates a missing file atstart()with aninfoline — so gating on resolution alone would letos dev --artifact ./typo.jsonover a host config boot green with NO registrar for the four kinds. The door is therefore composed only whenfs.existsSync(hmrArtifactPath), with the gate before the construction, and the warning names the missing path:The trade this makes is recorded in the code: on that boot the dev SSE endpoint is not mounted either, because the plugin that mounts it is the one not composed. Composing the door anyway and only withholding the registrar would keep that convenience by re-creating the two-writer divergence — the watcher picks the artifact up if it later appears and registers a second copy behind a wrap that already registered its own.
The
kernel.usecatch keeps its pre-existing one-line message. It is deliberately NOT the place to warn that the four collections went unregistered:Kernel.useonly validates the plugin and registers it by name, andinit/startrun later inbootstrap, so for an already-constructedMetadataPluginon a still-idlekernel that catch does not fire.Fences honoured
createStandaloneStackandpackages/metadata/**are untouched — this card is not #12892's, and that PR's fence holds.plugin-detection.ts(isHostConfig/shouldBootWithLibrary) was read, not tidied. Options 2 and 3 are closed by triage. PR #11336 untouched.Verification
Head
0337430d4c. The PR carries one fix commit, threeorigin/mainmerges, and three patch-round commits answering the contract review.Pin, red-first by ablation.
packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts. The ablation restores the pre-gateserve.tsfrom3dfd83a0e4and runs the pin, then restores fromHEADand runs it again. Both legs proven on disk by blob hash, never by an editor's exit code, with atrap ... EXIT INT TERMrestoring an absolute path:Exactly the two assertions the patch round added go red against the pre-gate wiring; the four that predate it stay green. An earlier ablation attempt on a freshly re-created worktree was NOT MEASURED, not a red — with no built dependency closure the pin's
@objectstack/runtimeimport failed to resolve and BOTH legs reportedTest Files 1 failed (1) / no tests. The numbers above are from the tree afterpnpm --filter '@objectstack/cli^...' build.Targeted, under the shared verify lock (
VERDICT command-exit 0):pnpm --filter @objectstack/cli typecheckEXIT=0; the pin EXIT=0,Test Files 1 passed (1) / Tests 6 passed (6). The package'stsconfig.jsonhasinclude: ["src"]and noexclude, so that typecheck does cover the test file.Whole-repo lint:
pnpm lint(eslint . --no-inline-config) EXIT=0, 153 s. Not narrowed.Gate union. At
3dfd83a0e4,dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 38 families from the real change set (3 paths, three-dot against the merge base); all 38 were run with exit codes captured before any pipe and recorded byte-for-byte, and--ranreconciled:✓ dispatch-gates --ran: 38 derived famil(ies) accounted for — 38 run, 0 NOT-MEASURED.Tally: 35 exit 0, zero findings, three NOT MEASURED in the gates' own words —check-test-completeness.mjsexit 3 (PREREQUISITE NOT MET, it grades a savedturbo run testlog; the gate itself says this is not a red),check-half-states.mjsexit 3 (PREREQUISITE NOT MET, repo-scoped reads answer 403 in this session class), andcheck:type-check-debtexit 124 (killed at a 540 s bound while--re-measurere-ran tsc per ledger entry; CI'slint.ymlmeasures it).That union still stands at the patched head, and the reason is checkable:
origin/mainhas not touched any of this PR's three files since the merge base, and the deriver emits the SAME 38 families from the SAME three paths — the command list is byte-identical (diffclean). What changed is content inside those paths, so the content-sensitive members were re-run at0337430d4c:pnpm check:nul-bytes,check-empty-changeset,check-changeset-no-major,check-keyed-text-bounds,check-comment-mask-adoption,check:test-source-alias,check:cross-package-test-inputs— all EXIT=0.One transient red is worth recording because it reads like a real finding:
pnpm check:dual-build-cjs-loadsonce reported 17@objectstack/spec"types NOT emitted though packages/spec/dist exists" findings — residue of a spec build this seat had killed mid-DTS while chasing the ratchet's prerequisite, a localdiststate and not a source defect. After a full rebuild (check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present) the gate is exit 0, and that is the run recorded above. Nothing in this PR touchespackages/spec.Shared verify lock. Heavily contended across both rounds (repeated
exit 99queue timeouts, holders named in eachVERDICTline). Locked runs: the dependency-closure builds, the typecheck and the pin. Declared narrowing, run unlocked: the ablations (single-file vitest runs over a source-reading test), thecheck:*gate union andpnpm lint—check:*gates andeslintare outside the lock's own stated coverage.Boot measurement rig
The two readings were produced with throwaway scripts under this session's scratchpad, outside the repo — nothing in this PR is instrumentation. The
examples/app-showcaserun used the app unmodified; the content reading used a temp host config whosenode_moduleswas symlinked to the showcase's so@objectstack/*resolved through the workspace.🤖 Generated with Claude Code
https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza